This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / routes / [handle] / +page.ts
12 kB 343 lines
1import type { Did } from "@atcute/lexicons/syntax"; 2import { createBobbinClient } from "$lib/api/client"; 3import { fetchPage } from "$lib/api/pagination"; 4import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 5import { type RepoRecord, type RecordList } from "$lib/api/records"; 6import { IdentityCache } from "$lib/api/identity"; 7import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8import { toHttpError } from "$lib/api/load"; 9import type { SearchPage } from "$lib/api/search"; 10import type { BobbinContext } from "$lib/api/client"; 11import { type VouchRecord } from "$lib/api/graph"; 12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15import type { 16 RepoCardData, 17 StringCardData, 18 PersonData, 19 VouchData, 20 StarData 21} from "$lib/components/profile/types"; 22import type { PageLoad } from "./$types"; 23 24const PAGE_LIMIT = 50; 25 26const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 27const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 28const FOLLOW_STATS: LinkDescriptor[] = [ 29 { source: "sh.tangled.graph.follow:subject", type: "count" }, 30 { source: "sh.tangled.graph.follow:.repo", type: "count" } 31]; 32const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 33 34const TABS = [ 35 "overview", 36 "repos", 37 "starred", 38 "strings", 39 "followers", 40 "following", 41 "vouches" 42] as const; 43type Tab = (typeof TABS)[number]; 44 45const normalizeTab = (raw: string | null): Tab => 46 TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 47 48interface ListItem { 49 uri: string; 50 value: unknown; 51} 52 53const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 54 const value = item.value as RepoRecord; 55 return { 56 rkey: rkeyFromUri(item.uri), 57 name: value.name ?? rkeyFromUri(item.uri), 58 repoDid: value.repoDid ?? "", 59 ownerHandle, 60 description: value.description, 61 knot: value.knot, 62 createdAt: value.createdAt 63 }; 64}; 65 66const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 67 const repo = toRepoCard(item, ownerHandle); 68 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 69 const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 70 const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 71 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 72}; 73 74const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 75 const value = item.value as ShTangledString.Main; 76 return { 77 rkey: rkeyFromUri(item.uri), 78 ownerHandle, 79 filename: value.filename, 80 description: value.description, 81 createdAt: value.createdAt, 82 lines: value.contents?.split("\n").length ?? 1 83 }; 84}; 85 86// resolves handles and avatars in input order, pulling follower counts and viewer status from the sidecar without extra requests 87const resolvePeople = async ( 88 ctx: BobbinContext, 89 dids: string[], 90 stats: Stats, 91 viewerDid?: string 92): Promise<PersonData[]> => { 93 const cache = new IdentityCache(ctx); 94 const unique = [...new Set(dids)]; 95 96 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 97 98 const byDid = new Map<string, PersonData>(); 99 unique.forEach((did, index) => { 100 const doc = docs[index]; 101 const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 102 const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 103 const isSelf = viewerDid === did; 104 const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 105 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 106 byDid.set( 107 did, 108 doc 109 ? { 110 did: doc.did, 111 handle: doc.handle, 112 avatar: doc.avatar, 113 followers, 114 following, 115 isSelf, 116 viewerFollowRkey 117 } 118 : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 119 ); 120 }); 121 return unique.map((did) => byDid.get(did) as PersonData); 122}; 123 124const resolveVouches = async ( 125 ctx: BobbinContext, 126 items: ListItem[], 127 direction: "incoming" | "outgoing" 128): Promise<VouchData[]> => { 129 const cache = new IdentityCache(ctx); 130 return Promise.all( 131 items.map(async (item): Promise<VouchData> => { 132 const value = item.value as VouchRecord; 133 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 134 const doc = await cache.resolve(otherDid).catch(() => null); 135 return { 136 uri: item.uri, 137 did: otherDid, 138 handle: doc?.handle ?? otherDid, 139 avatar: doc?.avatar, 140 kind: value.kind === "denounce" ? "denounce" : "vouch", 141 direction, 142 reason: value.reason, 143 createdAt: value.createdAt 144 }; 145 }) 146 ); 147}; 148 149const resolveStars = async ( 150 ctx: BobbinContext, 151 items: ListItem[], 152 viewerDid?: string 153): Promise<StarData[]> => { 154 const cache = new IdentityCache(ctx); 155 const repoDids = [ 156 ...new Set( 157 items 158 .map((item) => (item.value as ShTangledFeedStar.Main).subject) 159 .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) 160 ) 161 ]; 162 const enriched = 163 repoDids.length > 0 164 ? await enrich<RecordList<RepoRecord>>(ctx, { 165 xrpc: "sh.tangled.repo.getReposByRepoDids", 166 params: { dids: repoDids }, 167 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 168 ...(viewerDid ? { viewer: viewerDid } : {}) 169 }) 170 : { output: { items: [] }, stats: {} as Stats }; 171 const reposByDid = new Map( 172 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 173 ); 174 const resolved = await Promise.all( 175 items.map(async (item): Promise<StarData | null> => { 176 const value = item.value as ShTangledFeedStar.Main; 177 const subject = value.subject; 178 if (subject && "did" in subject && subject.did) { 179 const repo = reposByDid.get(subject.did); 180 if (!repo) return null; 181 const ownerDid = didFromUri(repo.uri); 182 const owner = await cache.resolve(ownerDid).catch(() => null); 183 return { 184 kind: "repo", 185 uri: item.uri, 186 createdAt: value.createdAt, 187 repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 188 }; 189 } 190 if (subject && "uri" in subject && subject.uri) { 191 const ownerDid = didFromUri(subject.uri); 192 const owner = await cache.resolve(ownerDid).catch(() => null); 193 return { 194 kind: "string", 195 uri: item.uri, 196 createdAt: value.createdAt, 197 ownerHandle: owner?.handle ?? ownerDid, 198 rkey: rkeyFromUri(subject.uri) 199 }; 200 } 201 return null; 202 }) 203 ); 204 return resolved.filter((star): star is StarData => star !== null); 205}; 206 207export const load: PageLoad = async (event) => { 208 const parent = await event.parent(); 209 const tab = normalizeTab(event.url.searchParams.get("tab")); 210 211 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; 212 213 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 214 const did = parent.identity.did as Did; 215 const handle = parent.identity.handle; 216 217 try { 218 switch (tab) { 219 case "repos": { 220 const q = event.url.searchParams.get("q")?.trim(); 221 const viewerDid = parent.auth?.did; 222 const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 223 if (!q) { 224 const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 225 xrpc: "sh.tangled.repo.listRepos", 226 params: { subject: did, limit: PAGE_LIMIT }, 227 enrich: viewerEnrich, 228 ...(viewerDid ? { viewer: viewerDid } : {}) 229 }); 230 return { 231 tab, 232 repos: enriched.output.items.map((item) => 233 resolveRepoCard(item, handle, enriched.stats) 234 ) 235 }; 236 } 237 const enriched = await enrich<SearchPage>(ctx, { 238 xrpc: "sh.tangled.search.query", 239 params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }, 240 enrich: viewerEnrich, 241 ...(viewerDid ? { viewer: viewerDid } : {}) 242 }); 243 return { 244 tab, 245 repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)) 246 }; 247 } 248 case "strings": { 249 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 250 subject: did, 251 limit: PAGE_LIMIT 252 }); 253 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 254 } 255 case "followers": { 256 const viewerDid = parent.auth?.did; 257 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 258 xrpc: "sh.tangled.graph.listFollows", 259 params: { subject: did, limit: PAGE_LIMIT }, 260 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 261 ...(viewerDid ? { viewer: viewerDid } : {}) 262 }); 263 const dids = enriched.output.items.map((item) => didFromUri(item.uri)); 264 return { 265 tab, 266 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 267 }; 268 } 269 case "following": { 270 const viewerDid = parent.auth?.did; 271 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 272 xrpc: "sh.tangled.graph.listFollowsBy", 273 params: { subject: did, limit: PAGE_LIMIT }, 274 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 275 ...(viewerDid ? { viewer: viewerDid } : {}) 276 }); 277 const dids = enriched.output.items.map( 278 (item) => (item.value as ShTangledGraphFollow.Main).subject 279 ); 280 return { 281 tab, 282 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 283 }; 284 } 285 case "vouches": { 286 const [incomingPage, outgoingPage] = await Promise.all([ 287 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }), 288 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT }) 289 ]); 290 const [incoming, outgoing] = await Promise.all([ 291 resolveVouches(ctx, incomingPage.items, "incoming"), 292 resolveVouches(ctx, outgoingPage.items, "outgoing") 293 ]); 294 const vouches = [...incoming, ...outgoing].sort( 295 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 296 ); 297 return { 298 tab, 299 vouches, 300 isSelf: parent.auth?.did === did, 301 profileHandle: handle 302 }; 303 } 304 case "starred": { 305 const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 306 subject: did, 307 limit: PAGE_LIMIT 308 }); 309 return { 310 tab, 311 stars: await resolveStars(ctx, page.items, parent.auth?.did) 312 }; 313 } 314 case "overview": 315 default: { 316 const viewerDid = parent.auth?.did; 317 const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 318 xrpc: "sh.tangled.repo.listRepos", 319 params: { subject: did, limit: PAGE_LIMIT }, 320 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 321 ...(viewerDid ? { viewer: viewerDid } : {}) 322 }); 323 324 const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 325 const byKey = new Map<string, ListItem>(); 326 for (const item of enriched.output.items) { 327 const value = item.value as RepoRecord; 328 if (value.repoDid) byKey.set(value.repoDid, item); 329 byKey.set(item.uri, item); 330 } 331 const pinnedItems = pinnedKeys 332 .map((key) => byKey.get(key)) 333 .filter((item): item is ListItem => item !== undefined); 334 const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats)); 335 336 return { tab: "overview" as const, overview: { pinned } }; 337 } 338 } 339 } catch (cause) { 340 console.error("Page load error:", cause); 341 toHttpError(cause, "Could not load profile data"); 342 } 343};